test(swift-sdk): mid flight added wallet integration test#4064
Conversation
📝 WalkthroughWalkthroughAdds a Swift integration test that funds multiple wallet addresses, imports the wallet during SPV synchronization with ChangesSPV mid-sync backfill
Estimated code review effort: 3 (Moderate) | ~15 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Test as IntegrationTest
participant WalletManager
participant SPVClient
participant ImportedWallet
Test->>WalletManager: Derive and fund multiple external addresses
Test->>SPVClient: Start synchronization
Test->>SPVClient: Import wallet with birthHeight 0
SPVClient->>ImportedWallet: Backfill wallet history
ImportedWallet-->>Test: Return synchronized balance
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
dc58d76 to
6479b65
Compare
6479b65 to
e40cd58
Compare
|
✅ Review complete (commit e40cd58) |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift`:
- Line 13: Replace the floating-point amountEachDuffs calculation with an
integer Duffs constant or integer-based representation, and update
amountEachDash usage as needed so values such as 0.0001 map exactly to 10000
Duffs without floating-point multiplication or truncation.
- Around line 129-136: Add a post-loop assertion after the address collection
loop in the relevant address-derivation helper, verifying that result.count
equals outCount and reporting a clear failure message if not, before any
subsequent funding-loop indexing occurs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c7cebbd2-68e1-46bf-817f-02d30eaf29db
📒 Files selected for processing (1)
packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift
|
|
||
| /// Funding amount per address, in DASH. | ||
| private let amountEachDash: Double = 0.001 | ||
| private var amountEachDuffs: UInt64 { UInt64(amountEachDash * 1e8) } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Floating-point conversion for Duffs is fragile.
UInt64(amountEachDash * 1e8) works for 0.001 (truncates 100000.000…002 to 100000), but breaks for other values — e.g. 0.0001 * 1e8 = 9999.999… truncates to 9999 instead of 10000. Define the amount in integer Duffs directly to avoid precision-dependent truncation.
🛡️ Proposed fix
- private let amountEachDash: Double = 0.001
- private var amountEachDuffs: UInt64 { UInt64(amountEachDash * 1e8) }
+ private let amountEachDuffs: UInt64 = 100_000 // 0.001 DASH
+ private var amountEachDash: Double { Double(amountEachDuffs) / 1e8 }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private var amountEachDuffs: UInt64 { UInt64(amountEachDash * 1e8) } | |
| private let amountEachDuffs: UInt64 = 100_000 // 0.001 DASH | |
| private var amountEachDash: Double { Double(amountEachDuffs) / 1e8 } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift`
at line 13, Replace the floating-point amountEachDuffs calculation with an
integer Duffs constant or integer-based representation, and update
amountEachDash usage as needed so values such as 0.0001 map exactly to 10000
Duffs without floating-point multiplication or truncation.
| var result: [String] = [] | ||
| result.reserveCapacity(outCount) | ||
| for i in 0..<outCount { | ||
| if let cstr = arr[i] { | ||
| result.append(String(cString: cstr)) | ||
| } | ||
| } | ||
| return result |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Validate derived address count matches requested count.
If the FFI returns fewer addresses than count (e.g. null entries skipped by the if let), result will have fewer elements and the subsequent addresses[i] access in the funding loop will crash with an unhelpful index-out-of-bounds error. Add a post-loop assertion to fail fast with a clear message.
🛡️ Proposed fix
for i in 0..<outCount {
if let cstr = arr[i] {
result.append(String(cString: cstr))
}
}
+ guard result.count == count else {
+ throw DerivationError(
+ description: "deriveExternalAddresses: expected \(count) addresses, got \(result.count)"
+ )
+ }
return result📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var result: [String] = [] | |
| result.reserveCapacity(outCount) | |
| for i in 0..<outCount { | |
| if let cstr = arr[i] { | |
| result.append(String(cString: cstr)) | |
| } | |
| } | |
| return result | |
| var result: [String] = [] | |
| result.reserveCapacity(outCount) | |
| for i in 0..<outCount { | |
| if let cstr = arr[i] { | |
| result.append(String(cString: cstr)) | |
| } | |
| } | |
| guard result.count == count else { | |
| throw DerivationError( | |
| description: "deriveExternalAddresses: expected \(count) addresses, got \(result.count)" | |
| ) | |
| } | |
| return result |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift`
around lines 129 - 136, Add a post-loop assertion after the address collection
loop in the relevant address-derivation helper, verifying that result.count
equals outCount and reporting a clear failure message if not, before any
subsequent funding-loop indexing occurs.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Source: Sol orchestrator openai/gpt-5.6-sol (high, orchestration-only); reviewers: Sol gpt-5.6-sol and Sonnet claude-sonnet-5 for general, security-auditor, and ffi-engineer; verifier: Sonnet claude-sonnet-5; Opus: not sampled (bucket 3).
This PR adds a single new Swift integration test (SpvManyTxMidSyncBackfillIntegrationTests.swift, +148 lines, no other files touched — confirmed against parent commit b42d213 at head e40cd58). The test's core FFI usage for bulk address derivation is correct, but it leaks a Rust-owned FFIWallet handle behind an incorrect ownership comment, bypasses the suite's InstantSend-aware funding helper, doesn't actually guarantee the mid-sync precondition it's named for, and only checks aggregate balance rather than full transaction history despite the test name promising 'finds all tx'.
🟡 4 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift:101-110: Leaked FFIWallet from wallet_manager_get_wallet; ownership comment is factually wrong
I checked the pinned key-wallet-ffi source directly (rev 647fa9820f3614090e4e5f5f2b709961d68e538b, cached at ~/.cargo/git/checkouts/rust-dashcore-*/647fa98/key-wallet-ffi/src/wallet_manager.rs:371-388). `wallet_manager_get_wallet` clones the wallet from the manager (`.cloned()`) and returns a brand-new heap allocation every call: `Box::into_raw(Box::new(FFIWallet::new(wallet)))`. Its doc comment is explicit: 'The returned wallet must be freed with wallet_free_const()', and `wallet_free_const` (wallet.rs:278-279) names this exact function by name as a caller of it. The manager does NOT retain ownership of this specific pointer.
The comment on line 101, 'Non-owning: the manager retains ownership of the wallet handle,' is incorrect, and there is no `defer { wallet_free_const(wallet) }` anywhere in `deriveExternalAddresses`. Every test run leaks one boxed `FFIWallet`. The same incorrect assumption already exists in production `WalletManager.swift` (confirmed: `wallet_free_const` is never called there despite 3 call sites of `wallet_manager_get_wallet`), so this is a pre-existing pattern rather than something this PR invented — but this PR adds a new call site with an incorrect rationale restated verbatim, and it's easy to fix here.
- [SUGGESTION] packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift:36-43: "~20% synced" precondition is not actually guaranteed — it depends on prior tests' chain growth, not this test's own state
I traced the harness: `IntegrationTestEnv.bootstrap()` (IntegrationTestEnv.swift:113-165) runs exactly once per test process, syncs the SPV data dir to the chain tip at that moment via `createSpvCache()`, then snapshots it with `snapshotSpvCache()`. Every subsequent test's `tearDown` calls `resetState()` → `restoreSpvCacheFromSnapshot()` (lines 78-82, 223-241), which restores the working SPV dir to that *same* pre-synced snapshot before the next test runs. Meanwhile the underlying regtest node keeps mining blocks across the whole suite — it is never reset.
So when this test calls `waitUntilUpToDate(height: tipHeight / 5)`, whether that wait does any real syncing work is entirely a function of how far the live chain has grown past the bootstrap-time snapshot height (i.e., how many blocks earlier tests in the same run happened to mine), not anything this test controls or asserts. `waitUntilUpToDate` (TestWallet.swift:69-89) returns as soon as cached headers/filters already meet the target — if the snapshot's cached height already exceeds `tipHeight/5` (plausible after any funding-heavy tests ran first), this reduces to importing into an already-synced client, silently testing the same late-import scenario `SpvLateWalletBackfillIntegrationTests` already covers instead of a genuine mid-flight race. Nothing in the test verifies headers/filters are actually below the current chain tip before `createWallet` runs.
- [SUGGESTION] packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift:55-69: Test name promises 'finds all tx' but only aggregate balance is asserted, not transaction history
The test method is `testManyTxImportedMidSyncBackfillsAllHistory` and the PR title says 'finds all tx', but the only assertion (lines 65-69) checks `imported.balance().total == expectedTotal`. Balance/UTXO tracking and transaction-history persistence are separate write paths — `PlatformWalletPersistenceHandler` independently writes rows into `PersistentTransaction`. A regression that restores the correct aggregate balance while dropping one or more of the 10 individual transaction records from history would pass this test undetected, since the 10 funding txids are never captured or checked against the imported wallet's history.
- [SUGGESTION] packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvManyTxMidSyncBackfillIntegrationTests.swift:26-32: Funding loop bypasses `env.fund()`'s InstantSend/masternode-broadcast steps
I confirmed `env.fund(address:dash:)` (IntegrationTestEnv.swift:391-398) does `sendToAddress` → `broadcastToMasternodes(txid:)` → `waitForInstantSendLock(txid:)` → `mine(1)`, and every other test in this suite (`SpvLateWalletBackfillIntegrationTests`, `CoreSendIntegrationTests`, `SpvRestartIntegrationTests`, etc.) uses this helper. This new test instead calls `env.coreRPC.sendToAddress` directly and immediately `env.mine(1)`, skipping the masternode broadcast and InstantSend-lock wait. Since this test funds 10 separate addresses across 10 blocks in quick succession, it's the test most likely to expose the absence of that synchronization the rest of the suite already guards against, making it a plausible source of CI flakiness.
| // Non-owning: the manager retains ownership of the wallet handle. | ||
| guard let wallet = walletId.withUnsafeBytes({ raw in | ||
| wallet_manager_get_wallet( | ||
| manager.ffiHandle, | ||
| raw.bindMemory(to: UInt8.self).baseAddress, | ||
| &error | ||
| ) | ||
| }) else { | ||
| throw DerivationError(description: "wallet_manager_get_wallet failed: \(Self.take(&error))") | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Leaked FFIWallet from wallet_manager_get_wallet; ownership comment is factually wrong
I checked the pinned key-wallet-ffi source directly (rev 647fa9820f3614090e4e5f5f2b709961d68e538b, cached at ~/.cargo/git/checkouts/rust-dashcore-*/647fa98/key-wallet-ffi/src/wallet_manager.rs:371-388). wallet_manager_get_wallet clones the wallet from the manager (.cloned()) and returns a brand-new heap allocation every call: Box::into_raw(Box::new(FFIWallet::new(wallet))). Its doc comment is explicit: 'The returned wallet must be freed with wallet_free_const()', and wallet_free_const (wallet.rs:278-279) names this exact function by name as a caller of it. The manager does NOT retain ownership of this specific pointer.
The comment on line 101, 'Non-owning: the manager retains ownership of the wallet handle,' is incorrect, and there is no defer { wallet_free_const(wallet) } anywhere in deriveExternalAddresses. Every test run leaks one boxed FFIWallet. The same incorrect assumption already exists in production WalletManager.swift (confirmed: wallet_free_const is never called there despite 3 call sites of wallet_manager_get_wallet), so this is a pre-existing pattern rather than something this PR invented — but this PR adds a new call site with an incorrect rationale restated verbatim, and it's easy to fix here.
| // Non-owning: the manager retains ownership of the wallet handle. | |
| guard let wallet = walletId.withUnsafeBytes({ raw in | |
| wallet_manager_get_wallet( | |
| manager.ffiHandle, | |
| raw.bindMemory(to: UInt8.self).baseAddress, | |
| &error | |
| ) | |
| }) else { | |
| throw DerivationError(description: "wallet_manager_get_wallet failed: \(Self.take(&error))") | |
| } | |
| // Owning: the manager clones the wallet on each call; the caller | |
| // must free it with wallet_free_const(). | |
| guard let wallet = walletId.withUnsafeBytes({ raw in | |
| wallet_manager_get_wallet( | |
| manager.ffiHandle, | |
| raw.bindMemory(to: UInt8.self).baseAddress, | |
| &error | |
| ) | |
| }) else { | |
| throw DerivationError(description: "wallet_manager_get_wallet failed: \(Self.take(&error))") | |
| } | |
| defer { wallet_free_const(wallet) } | |
source: ['codex-ffi-engineer', 'sonnet5-ffi-engineer']
| // Start the SPV client EMPTY, then wait until it is ~20% synced so the | ||
| // wallet is imported genuinely mid-sync. | ||
| try await env.walletManager.startSpv(config: env.spvConfig) | ||
| let tipHeight = try await env.coreRPC.getBlockCount() | ||
| try await env.walletManager.waitUntilUpToDate( | ||
| height: tipHeight / 5, | ||
| timeout: 180 | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: "~20% synced" precondition is not actually guaranteed — it depends on prior tests' chain growth, not this test's own state
I traced the harness: IntegrationTestEnv.bootstrap() (IntegrationTestEnv.swift:113-165) runs exactly once per test process, syncs the SPV data dir to the chain tip at that moment via createSpvCache(), then snapshots it with snapshotSpvCache(). Every subsequent test's tearDown calls resetState() → restoreSpvCacheFromSnapshot() (lines 78-82, 223-241), which restores the working SPV dir to that same pre-synced snapshot before the next test runs. Meanwhile the underlying regtest node keeps mining blocks across the whole suite — it is never reset.
So when this test calls waitUntilUpToDate(height: tipHeight / 5), whether that wait does any real syncing work is entirely a function of how far the live chain has grown past the bootstrap-time snapshot height (i.e., how many blocks earlier tests in the same run happened to mine), not anything this test controls or asserts. waitUntilUpToDate (TestWallet.swift:69-89) returns as soon as cached headers/filters already meet the target — if the snapshot's cached height already exceeds tipHeight/5 (plausible after any funding-heavy tests ran first), this reduces to importing into an already-synced client, silently testing the same late-import scenario SpvLateWalletBackfillIntegrationTests already covers instead of a genuine mid-flight race. Nothing in the test verifies headers/filters are actually below the current chain tip before createWallet runs.
source: ['codex-general']
| // Wait until the wallet is fully synced, then check that the balance is | ||
| try await env.walletManager.waitUntilUpToDate( | ||
| height: try await env.coreRPC.getBlockCount(), | ||
| timeout: 180 | ||
| ) | ||
| _ = try? await Wait.until("balance reaches expected", timeout: 120, pollInterval: 0.5) { | ||
| try imported.balance().total == expectedTotal | ||
| } | ||
| let atFullSync = try imported.balance().total | ||
|
|
||
| XCTAssertEqual( | ||
| atFullSync, | ||
| expectedTotal, | ||
| "atFullSync=\(atFullSync) expected=\(expectedTotal)" | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: Test name promises 'finds all tx' but only aggregate balance is asserted, not transaction history
The test method is testManyTxImportedMidSyncBackfillsAllHistory and the PR title says 'finds all tx', but the only assertion (lines 65-69) checks imported.balance().total == expectedTotal. Balance/UTXO tracking and transaction-history persistence are separate write paths — PlatformWalletPersistenceHandler independently writes rows into PersistentTransaction. A regression that restores the correct aggregate balance while dropping one or more of the 10 individual transaction records from history would pass this test undetected, since the 10 funding txids are never captured or checked against the imported wallet's history.
source: ['codex-general']
| // One funding tx per block. | ||
| for i in 0..<numTxs { | ||
| _ = try await env.coreRPC.sendToAddress( | ||
| amount: amountEachDash, address: addresses[i] | ||
| ) | ||
| _ = try await env.mine(1) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Funding loop bypasses env.fund()'s InstantSend/masternode-broadcast steps
I confirmed env.fund(address:dash:) (IntegrationTestEnv.swift:391-398) does sendToAddress → broadcastToMasternodes(txid:) → waitForInstantSendLock(txid:) → mine(1), and every other test in this suite (SpvLateWalletBackfillIntegrationTests, CoreSendIntegrationTests, SpvRestartIntegrationTests, etc.) uses this helper. This new test instead calls env.coreRPC.sendToAddress directly and immediately env.mine(1), skipping the masternode broadcast and InstantSend-lock wait. Since this test funds 10 separate addresses across 10 blocks in quick succession, it's the test most likely to expose the absence of that synchronization the rest of the suite already guards against, making it a plausible source of CI flakiness.
source: ['sonnet5-general']
| birthHeight: 0 | ||
| ) | ||
|
|
||
| // Wait until the wallet is fully synced, then check that the balance is |
There was a problem hiding this comment.
💬 Nitpick: Comment is truncated mid-sentence
"Wait until the wallet is fully synced, then check that the balance is" cuts off before completing the thought. Confirmed verbatim in the file.
| // Wait until the wallet is fully synced, then check that the balance is | |
| // Wait until the wallet is fully synced, then check that the balance | |
| // reaches the expected total. |
source: ['sonnet5-general']
| let addresses = try Self.deriveExternalAddresses( | ||
| manager: km, walletId: walletId, count: numTxs | ||
| ) | ||
|
|
||
| // One funding tx per block. | ||
| for i in 0..<numTxs { | ||
| _ = try await env.coreRPC.sendToAddress( | ||
| amount: amountEachDash, address: addresses[i] |
There was a problem hiding this comment.
💬 Nitpick: No guard that deriveExternalAddresses returned exactly numTxs addresses before indexing
deriveExternalAddresses skips any null C-string entries when building result (line 132: if let cstr = arr[i]), so it could in principle return fewer than count addresses. The funding loop then indexes addresses[i] for i in 0..<numTxs unconditionally, which would crash with an out-of-range index rather than a clear assertion failure if that ever happened. Low likelihood given the FFI contract, but a cheap XCTAssertEqual(addresses.count, numTxs) before the loop would fail more legibly.
source: ['sonnet5-general']
Issue being fixed or feature implemented
What was done?
How Has This Been Tested?
Breaking Changes
Checklist:
For repository code-owners and collaborators only
Summary by CodeRabbit